Skip to content

subprocess_venv: two-stage install to fix torch/torchaudio CUDA-tag s…#518

Merged
wilke0818 merged 1 commit into
20260512-204619-fix-canary-cuda-conflictfrom
fix/cuda-skew-two-stage
May 15, 2026
Merged

subprocess_venv: two-stage install to fix torch/torchaudio CUDA-tag s…#518
wilke0818 merged 1 commit into
20260512-204619-fix-canary-cuda-conflictfrom
fix/cuda-skew-two-stage

Conversation

@wilke0818

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #516. The --index-url <cuda> --extra-index-url pypi pattern doesn't actually route torch / torchaudio through the chosen PyTorch wheel index in uv.
Splitting the install into two stages (CUDA-index for torch+torchaudio, then default PyPI for everything else) makes the routing real, eliminates the cu128/cu129 ABI mismatch
this PR was meant to fix, and keeps every behavioral guarantee #516 introduced.

Why

While testing #516 on ORCD, the canary venv consistently rebuilt into the same broken state #516 is trying to prevent:

RuntimeError: Detected that PyTorch and TorchAudio were compiled with
different CUDA versions. PyTorch has CUDA version 12.9 whereas
TorchAudio has CUDA version 12.8.

Reproduced even with SENSELAB_TORCH_INDEX_URL=https://download.pytorch.org/whl/cu128 explicitly set:

$ ls ~/.cache/senselab/venvs/nemo-canary-qwen/lib/python3.12/site-packages/ \
    | grep -E "^(torch|torchaudio)-[0-9].*dist-info$"
torch-2.8.0+cu129.dist-info       # from PyPI — has +cu129 local tag
torchaudio-2.8.0.dist-info        # from PyPI — no local tag, internally cu128

The marker reported torch_index.url == https://download.pytorch.org/whl/cu128 (override was honored at the routing-decision layer), but the install argv still landed both
wheels from PyPI.

Root cause

uv treats --extra-index-url as taking precedence over --index-url (opposite of pip). Quoting uv's own docs: "uv treats --extra-index-url as a list of extra indexes
that take precedence over --index-url."
So in #516's install command:

uv pip install \
  --index-url https://download.pytorch.org/whl/cu128 \
  --extra-index-url https://pypi.org/simple \
  <requirements>

PyPI is consulted first for every package. PyPI has both torch and torchaudio, so the CUDA index is effectively never used for them. The two wheels come from PyPI with
mismatched local-version tags, and the env override path is non-functional too.

Verified directly: running uv pip install --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps torch torchaudio (no --extra-index-url) correctly
installed matched +cu128 wheels into the same venv.

The fix

Split the install into two stages.

Stage 1uv pip install --index-url <chosen> torch torchaudio with no --extra-index-url. The chosen CUDA index is unambiguously primary; both wheels and their
nvidia-cuda-runtime-cu12 transitives come from the same toolchain. Any version pin in the caller's requirements (e.g. torch>=2.8,<2.9) flows through verbatim; backends
that don't pin torch (e.g. qwen) fall back to bare names.

Stage 2uv pip install <rest of requirements> safetensors numpy with no index flags. uv resolves against default PyPI, sees torch / torchaudio already installed
and satisfying any pin, and leaves them alone. The PyTorch index now governs only the two packages it's designed for, and stale wheels on the CUDA index for utilities like
setuptools / pyarrow stay out of the picture.

torchaudio is no longer re-appended in Stage 2 — it's installed in Stage 1, and re-listing it without an index flag would let uv consider replacing the matched wheel with
PyPI's tagless one.

Behavioral guarantees preserved from #516

  • Marker schema (torch_index field with tag / url / source).
  • Cache-hit fast path (marker compares requirements + torch_index.url).
  • Env override (SENSELAB_TORCH_INDEX_URL) — flows into Stage 1 unchanged.
  • SenselabCudaCompatibilityError wrapping on wheel-not-found errors, now scoped to Stage 1 where it semantically belongs (Stage 2 hits default PyPI, so a failure there
    isn't a CUDA-compat surface).
  • Pass-through of unrelated CalledProcessErrors.
  • Half-built-venv cleanup on either stage's failure.
  • Host-CUDA probe diagnostic surface.
  • Cross-backend routing (canary, nemo, qwen all hit the same ensure_venv).

Tests

The 14 existing subprocess_venv_test.py tests are kept; 4 needed call-count or argv-shape updates for the new two-stage form, and the single install-argv test was split
into three more specific ones:

  • test_stage_one_pins_torch_and_torchaudio_to_chosen_index — Stage 1 names only --index-url, no --extra-index-url, and includes the torch + torchaudio specs from
    requirements.
  • test_stage_one_uses_bare_names_when_requirements_omit_torch — qwen-style backend (no explicit torch pin) still gets torch + torchaudio installed in Stage 1 so transitives
    don't pull them in later from PyPI.
  • test_stage_two_uses_default_pypi_and_includes_ipc_deps — Stage 2 carries neither --index-url nor --extra-index-url, includes safetensors + numpy, and does NOT
    re-list torchaudio.

The cross-backend regression test (test_all_three_subprocess_backends_route_through_same_torch_index) now asserts both that Stage 1 routes through the chosen index AND that
Stage 2 does not — guarding against any future re-introduction of --extra-index-url on the torch install. The SenselabCudaCompatibilityError wrapping test and the
unrelated-error pass-through test were unchanged (they fire on the first uv pip install call, which is now Stage 1 — semantically correct).

All 30 unit/integration tests pass:

$ uv run pytest src/tests/utils/subprocess_venv_test.py \
                src/tests/utils/cuda_probe_test.py --noconftest -q
..............................                                           [100%]
30 passed, 2 warnings in 9.28s

Out-of-band verification

Reproduced the bug, applied this fix, and verified end-to-end on an ORCD compute node:

  • Cleared ~/.cache/senselab/venvs/nemo-canary-qwen to force a fresh build.
  • Re-ran a canary loader with no env override set, on a non-GPU interactive shell where the probe correctly picks cpu.
  • Stage 1 installed matched torch-2.8.0+cpu and torchaudio-2.8.0+cpu (clean CPU path — no PyPI fallback leaking cu128/cu129).
  • Stage 2 installed NeMo from PyPI without disturbing torch/torchaudio.
  • Canary transcribed audio without RuntimeError. CPU path proven functional.

Inside a srun --gres=gpu:1 allocation the probe picks cu128 from the static map and Stage 1 installs matched +cu128 wheels — same outcome, GPU-capable.

Test plan

  • uv run pytest src/tests/utils/subprocess_venv_test.py src/tests/utils/cuda_probe_test.py --noconftest — all 30 tests pass.
  • Fresh canary venv build with SENSELAB_VENV_CACHE redirected to a throwaway dir; verified torch + torchaudio wheels carry matching local-version tags after the
    install.
  • temp/asr.py runs canary end-to-end against a real audio clip; no RuntimeError, transcript returned.
  • Cross-check on a fresh cu13x-driver host that the matched-tag outcome holds under the probe path (no override).

@wilke0818
wilke0818 requested a review from satra May 14, 2026 17:05

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a two-stage installation process for virtual environments using uv to resolve an ABI mismatch issue caused by index priority. Stage 1 installs torch and torchaudio from a specific CUDA index, while Stage 2 installs the remaining requirements from PyPI. Feedback suggests filtering torch-related packages from the Stage 2 requirements to prevent re-resolution and improving the _torch_install_specs function to handle multiple version constraints for the same package.

Comment thread src/senselab/utils/subprocess_venv.py Outdated
# via default PyPI. ``torchaudio`` is already installed in Stage 1
# so it isn't re-added here; safetensors + numpy are pure-Python
# and pull cleanly from PyPI on every supported platform.
stage_two_reqs = [*requirements, "safetensors", "numpy"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure that torch and torchaudio are never re-resolved against the default PyPI index in Stage 2, it is safer to filter them out of the requirements list passed to the second subprocess.run. The PR description notes that re-listing them without an index flag could lead uv to replace the matched wheels with PyPI's tagless ones. While this was addressed for the explicit torchaudio append, backends that include these packages in their own requirements (like NeMo or Canary) still re-list them in Stage 2, which might trigger the behavior you are trying to avoid.

Suggested change
stage_two_reqs = [*requirements, "safetensors", "numpy"]
stage_two_reqs = [r for r in requirements if _spec_pkg_name(r) not in _TORCH_PKG_NAMES] + ["safetensors", "numpy"]

Comment thread src/senselab/utils/subprocess_venv.py Outdated
Comment on lines +434 to +439
pinned: dict[str, str] = {}
for spec in requirements:
name = _spec_pkg_name(spec)
if name in _TORCH_PKG_NAMES:
pinned[name] = spec
return [pinned.get(name, name) for name in ("torch", "torchaudio")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of _torch_install_specs only keeps the last constraint found in requirements for each package because it uses a dictionary. If a backend specifies multiple constraints for torch (e.g., ["torch>=2.8", "torch<2.9"]), only one will be passed to the Stage 1 install. This could lead to Stage 1 installing a version that doesn't satisfy the other constraint, which Stage 2 might then try to "fix" by pulling from PyPI, defeating the routing logic. It is safer to collect and return all matching specs.

    out = []
    found = set()
    for spec in requirements:
        name = _spec_pkg_name(spec)
        if name in _TORCH_PKG_NAMES:
            out.append(spec)
            found.add(name)
    for name in ("torch", "torchaudio"):
        if name not in found:
            out.append(name)
    return out

@satra

satra commented May 14, 2026

Copy link
Copy Markdown
Collaborator

the only consideration is that if a subprocess venv does not require torchaudio (or relies only on torchcodec), this forces it. especially as this approach is meant not just for legacy environments but also when the environment needs something from simply a github repo.

@wilke0818
wilke0818 force-pushed the fix/cuda-skew-two-stage branch from 3f0cb35 to bc98113 Compare May 14, 2026 17:27
@wilke0818

Copy link
Copy Markdown
Collaborator Author

Added a requires_torch: bool = True parameter to ensure_venv. Default keeps the existing behavior so all current callers (canary, nemo, qwen, yamnet, coqui, ppgs, sparc)
are unchanged. Passing requires_torch=False collapses the install back to a single pass:

  • Skips the nvidia-smi / nvcc probe entirely (so login nodes / no-GPU shells don't spend the few seconds shelling out).
  • Skips Stage 1 — no torch / torchaudio install, no CUDA index in the argv.
  • Skips the IPC torchaudio append (the safetensors + numpy IPC deps still get installed since those are pure-Python and cheap everywhere).
  • Marker omits the torch_index field; if the same venv name is later called with requires_torch=True, the existing URL-mismatch clause invalidates the cache and rebuilds
    correctly. Covered by test_requires_torch_false_then_true_invalidates_cache.

So a future backend that consumes a pure-Python GitHub repo can declare:

ensure_venv("my-backend", ["some-repo @ git+https://..."], python_version="3.12", requires_torch=False)

and pay none of the multi-GB CUDA wheel cost. Test coverage in test_requires_torch_false_skips_probe_and_stage_one asserts the probe is never invoked and the single install
argv has neither --index-url nor torchaudio.

Worth flagging that the current all_reqs = [..., "safetensors", "numpy", "torchaudio"] line predates this PRthe unconditional torchaudio append goes back to the original
ensure_venv implementation. This patch is the first place that lets a caller opt out of it. If we want to be more aggressive (e.g. drop torchaudio from the default IPC list
and only add it when a backend explicitly needs it for FLAC encoding), that's a separate refactor — happy to do it as a follow-up if you'd rather change the default than gate
 it behind a flag.

@satra

satra commented May 14, 2026

Copy link
Copy Markdown
Collaborator

but torch doesn't always require torchaudio, does it? i thought it was removed as a requirement. so the only time this comes in is when some other thing requires it.

@wilke0818

Copy link
Copy Markdown
Collaborator Author

Right — torch doesn't pull torchaudio (the dep direction is the other way). The unconditional torchaudio append in ensure_venv is a senselab IPC convention from #446
(14ab40c7)
, added "for FLAC audio encoding" because the IPC layer serializes Audio objects as .flac via
torchaudio.save() / torchaudio.load().

So your point is sharper than what requires_torch=False actually covers in this PR. That opt-out handles the no-torch-at-all case, but you're flagging the orthogonal one:
torch-yes, torchaudio-no — a backend that uses torch but never moves Audio through FLAC IPC (or uses torchcodec for its own decoding) still pays for torchaudio today.

Honest fix is to drop the unconditional append from ensure_venv and require each backend to declare torchaudio in its own _REQUIREMENTS only when it actually does Audio
IPC. Looking at current callers:

  • canary, nemo — already pin torchaudio>=2.8,<2.9 explicitly, so unaffected.
  • qwen — relies on qwen-asr's transitive torchaudio dep; would need an explicit line.
  • yamnet, coqui, ppgs, sparc — currently rely on the implicit append; each would need to add torchaudio explicitly if its IPC path round-trips Audio via FLAC, or stay
    silent if it doesn't.

Two ways to land that:

  1. In this PR. ~20 lines across the four affected backends plus one base-line removal from ensure_venv. Keeps the CUDA fix and the IPC cleanup together since they're
    closely related, but mixes two behavioral changes in one diff.
  2. As a follow-up. Clean blame separation — this PR stays focused on the CUDA-tag split, the next one cleans up the IPC convention.

Mild preference for (2) since each PR's effect is then isolated and bisectable, but (1) is straightforward if you'd rather not see two PRs back-to-back. Which way?

…equirements

PR #516 routes the install through ``--index-url <cuda> --extra-index-url
https://pypi.org/simple`` so non-torch packages can still resolve from
PyPI. But uv treats ``--extra-index-url`` as having higher priority than
``--index-url`` (opposite of pip), so PyPI wins for every package — torch
and torchaudio included. On hosts where PyPI ships those with mismatched
``+cu`` local-version tags (currently ``torch==X+cu129`` vs
``torchaudio==X`` with no tag, internally cu128), the resulting venv
reproduces the same ABI mismatch this PR is meant to fix:

    RuntimeError: Detected that PyTorch and TorchAudio were compiled with
    different CUDA versions. PyTorch has CUDA version 12.9 whereas
    TorchAudio has CUDA version 12.8.

Reproduced on MIT ORCD with the env override set
(``SENSELAB_TORCH_INDEX_URL=https://download.pytorch.org/whl/cu128``) —
override URL was honored in the marker but uv still resolved both wheels
from PyPI. So the override path was non-functional too.

Fix: split the install into two stages and decide whether Stage 1 fires
from the caller's ``requirements`` itself.

  Stage 1 — ``uv pip install --index-url <chosen>`` listing every
    ``torch`` / ``torchaudio`` spec found in ``requirements``, with NO
    ``--extra-index-url``. The chosen CUDA index is unambiguously
    primary; both wheels and their ``nvidia-cuda-runtime-cu12``
    transitives come from the same toolchain. Multiple constraints
    for the same package (``["torch>=2.8", "torch<2.9"]``) are all
    forwarded so uv combines them at resolve time.

  Stage 2 — ``uv pip install <rest of requirements> safetensors numpy``
    with no index flags. Backend-pinned torch / torchaudio specs are
    filtered out so uv can't be tempted to re-resolve them from PyPI
    against the matched ``+cu128`` wheels installed in Stage 1.

Drop the unconditional ``torchaudio`` IPC append, in two ways:

1. ``_torch_install_specs`` no longer pads its return with bare
   ``torch`` / ``torchaudio`` names. If neither is in ``requirements``,
   it returns ``[]``.
2. ``ensure_venv`` treats ``_torch_install_specs(requirements) == []``
   as the trigger to skip the probe entirely and run a single
   torch-free install pass against default PyPI. No ``nvidia-smi``
   shellout, no ``torchaudio`` force-appended.

This addresses @satra's review concern that the previous default forced
``torchaudio`` into every subprocess venv, even backends that don't use
it. With the change, ``yamnet`` and ``continuous-ser`` — both of which
read audio via ``soundfile`` in their worker scripts and never import
torchaudio — get ~200 MB leaner venvs. Backends that genuinely need
``torch`` / ``torchaudio`` (including via a transitive dep) MUST pin
them explicitly so Stage 1 routes them through the matched CUDA index;
this is also why ``qwen.py``'s ``_REQUIREMENTS`` now names them
directly even though ``qwen-asr==0.0.6`` would otherwise pull them
transitively — relying on the transitive would let Stage 2's PyPI
resolution split the local-version tags. The 7 backends that already
pin ``torchaudio`` explicitly (canary, nemo, ppgs, sparc, coqui,
s3prl) are unaffected.

The earlier ``requires_torch=False`` parameter from a previous round
of this review (an explicit opt-out flag) is removed in favor of the
auto-detection — it's structurally equivalent and the explicit flag
becomes redundant noise.

Behavioral guarantees preserved from PR #516: marker schema (extended,
not changed), cache-hit fast path, env override
(``SENSELAB_TORCH_INDEX_URL``), ``SenselabCudaCompatibilityError``
wrapping on wheel-not-found errors (now scoped to Stage 1 where it
semantically belongs), pass-through of unrelated ``CalledProcessError``,
half-built-venv cleanup on failure, host-CUDA probe diagnostic surface,
cross-backend routing.

Tests: 34 in total covering (1) Stage 1 names only ``--index-url`` with
torch + torchaudio pinned per requirements; (2) Stage 2 carries no
index flags + IPC deps; (3) Stage 2 filters torch / torchaudio specs
from caller's requirements; (4) multiple constraints for the same
package all forward through Stage 1 verbatim; (5) requirements without
torch / torchaudio skip the probe and Stage 1 entirely; (6) yamnet-
style requirements get NO torchaudio in their install argv; (7)
switching a venv name from torch-free to torch-using correctly
invalidates the cache and rebuilds. The cross-backend regression test
asserts both that Stage 1 routes through the chosen index AND that
Stage 2 contains no torch / torchaudio specs at all.

Review feedback addressed:
- @satra (PR #518 review): drop the unconditional ``torchaudio`` IPC
  append; auto-detect torch routing from the caller's ``requirements``
  instead of forcing every venv to pay for it. Adds explicit ``torch``
  + ``torchaudio`` to qwen's ``_REQUIREMENTS`` so its transitive-dep
  resolution still flows through the CUDA index.
- gemini-code-assist #1: filter torch / torchaudio specs from Stage 2.
- gemini-code-assist #2: list-based ``_torch_install_specs`` keeps
  every matching constraint instead of clobbering through a dict.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants